package edu.uw.cs.cse461.sp12.OS;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;

import org.json.JSONException;
import org.json.JSONObject;

import edu.uw.cs.cse461.sp12.util.IPFinder;
import edu.uw.cs.cse461.sp12.util.Log;
import edu.uw.cs.cse461.sp12.util.TCPMessageHandler;

/**
 * Implements the side of RPC that receives remote invocation requests.
 * 
 * @author zahorjan
 *
 */
public class RPCService extends RPCCallable {
	// used with the android idiom Log.x, as in Log.v(TAG, "some debug log message")
	private static final String TAG="RPCService";
	
	private ServerSocket mServerSocket;
	
	private Map<String, Map<String, RPCCallableMethod>> handlerMap;
	
	private ServiceLoop serviceLoop;
	
	private Random rand;
	
	private static final int MAX_CONNECTIONS = 10;
	
	/**
	 * Number of connections open
	 */
	private Integer connections;
	
	
	/**
	 * This method must be implemented by RPCCallable's.  
	 * "rpc" is the well-known name of this service.
	 */
	@Override
	public String servicename() {
		return "rpc";
	}
	
	/**
	 * Constructor.  Creates the Java ServerSocket and binds it to a port.
	 * If the config file specifies an rpc.serverport value, it should be bound to that port.
	 * Otherwise, you should specify port 0, meaning the operating system should choose a currently unused port.
	 * (The config file settings are available via the OS object.)
	 * <p>
	 * Once the port is created, a thread needs to be created to listen for connections on it.
	 * 
	 * @throws Exception
	 */
	RPCService() throws Exception {

		// Instantiate the handlerMap
		handlerMap = new HashMap<String, Map<String, RPCCallableMethod>>();
		
		int serverPort = Integer.parseInt(OS.config().getProperty("rpc.serverport", "0"));
		// Set some socket options. 
		mServerSocket = new ServerSocket(serverPort);

		// setReuseAddress lets you reuse a server port immediately after terminating
		// an application that has used it.  (Normally that port is unavailable for a while, for reasons we'll see
		// later in the course.
		mServerSocket.setReuseAddress(true); // allow port number to be reused immediately after close of this socket
		// setSoTimeout causes a thread waiting for connections to timeout, instead of waiting forever, if no connection
		// is made before the timeout interval expires.  (You don't have to use 1/2 sec. for this value - choose your own.)
		mServerSocket.setSoTimeout(1000); // well, we have to wake up every once and a while to check for program termination

		connections = 0;
		
		// For random IDs
		rand = new Random();
		
		// Create a new ServiceLoop which will sit in a loop and call accept and process requests until shutdown() is called
		serviceLoop = new ServiceLoop();
		new Thread(serviceLoop).start();
	}
	
	/**
	 * System is shutting down imminently.  Do any cleanup required.
	 */
	public void shutdown() {
		// Stop the serviceLoop from continuing to accept new requests
		serviceLoop.requestStop();
		
		try {
			mServerSocket.close();
		} catch (IOException e) {
			Log.e(TAG, "Could not close server socket: " + e.getMessage());
		}
	}
	
	/**
	 * Services and applications with RPC callable methods register them with the RPC service using this routine.
	 * Those methods are then invoked as callbacks when an remote RPC request for them arrives.
	 * @param serviceName  The name of the service.
	 * @param methodName  The external, well-known name of the service's method to call
	 * @param method The descriptor allowing invocation of the Java method implementing the call
	 * @throws Exception if the serviceName and methodName have already been registered
	 */
	public synchronized void registerHandler(String serviceName, String methodName, RPCCallableMethod method) throws Exception {
		// Get map for the service name
		Map<String, RPCCallableMethod> serviceNameMap = handlerMap.get(serviceName);
		
		if (serviceNameMap == null) {
			serviceNameMap = new HashMap<String, RPCCallableMethod>();
			handlerMap.put(serviceName, serviceNameMap);
		}
		
		if (!serviceNameMap.containsKey(methodName)) {
			serviceNameMap.put(methodName, method);
		} else {
			// Throw exception (already have a service and method of this description)
			throw new RuntimeException("(" + serviceName + ", " + methodName + ") has already been registered");
		}
	}
	
	/**
	 * Returns the local IP address.
	 * @return
	 * @throws UnknownHostException
	 */
	public String localIP() throws UnknownHostException {
		return IPFinder.getIpAddress();
	}

	/**
	 * Returns the port to which the RPC ServerSocket is bound.
	 * @return
	 */
	public int localPort() {
		return mServerSocket.getLocalPort();
	}
	
	private class ServiceLoop implements Runnable {
		// Tag for this specific class (used in Logging)
		private static final String TAG = "ServiceLoop";
		
		// Whether a request to stop the server has been sent
		private boolean requestStop;
		
		public ServiceLoop() {
			this.requestStop = false;
		}
		
		@Override
		public void run() {
			// Keep going while we haven't requested a stop yet
			while(!requestStop) {
				// Get a socket through accept
				Socket sock = null;
				try {
					sock = mServerSocket.accept();
				} catch (IOException e) {
					// Accept timed out.  Don't do anything...will find out if requestStop() has been 
					// called on next iteration of loop
				}
				
				if (sock != null) {
					try {
						TCPMessageHandler msgHandler = new TCPMessageHandler(sock);
						
						// Do the handshake
						JSONObject handshakeRequest = msgHandler.readMessageAsJSONObject();
						Log.d(TAG, "Handshake Request Received: " + handshakeRequest.toString());
						
						JSONObject handshakeResponse = createEmptyResponse(handshakeRequest.getInt("id"));
						
						boolean handshakeSuccess = true;

						synchronized (connections) {
							if (connections >= MAX_CONNECTIONS){
								Log.d(TAG, "Max connections reached: " + connections);
								handshakeSuccess = false;
							} else {
								connections++;
							}
						}
						Log.d(TAG, "connections: " + connections);
						
						if (handshakeSuccess){
							String handshakeAction = handshakeRequest.getString("action");
							String handshakeType = handshakeRequest.getString("type");
							if (handshakeAction.equals("connect") && handshakeType.equals("control")) {
								addOKResponse(handshakeResponse, null);
							} else {
								// Error.  Return error message
								handshakeSuccess = false;
								
								addErrorResponse(handshakeResponse, "Invalid request", null);
							}
							msgHandler.sendMessage(handshakeResponse);
							
							if(handshakeSuccess){
								new Thread(new RPCCall(msgHandler, sock)).start();
							}
						} else {
							addErrorResponse(handshakeResponse, "Maximum connections exceeded", null);
							msgHandler.sendMessage(handshakeResponse);
							
							// Close the socket
							sock.close();
						}
					} catch (JSONException e){
						Log.e(TAG, "Error processing JSON object: " + e.getMessage());
					} catch (IOException e) {
						Log.e(TAG, "Error while processing request: " + e.getMessage());
					}
					
				}
			}
		}
		
		public void requestStop() {
			this.requestStop = true;
		}
	}
	
	private class RPCCall implements Runnable{
		private TCPMessageHandler msgHandler;
		private Socket socket;
		
		public RPCCall(TCPMessageHandler msgHandler, Socket sock){
			this.msgHandler = msgHandler;
			this.socket = sock;
		}

		@Override
		public void run() {
			try {

				JSONObject invokeRequest = msgHandler.readMessageAsJSONObject();
				Log.d(TAG, "Invoke Request Received: " + invokeRequest.toString());
				
				JSONObject invokeResponse = createEmptyResponse(invokeRequest.getInt("id"));
				
				String invokeType = invokeRequest.getString("type");
				if (invokeType.equals("invoke")) {
					String invokeApp = invokeRequest.getString("app");
					String invokeMethod = invokeRequest.getString("method");
					
					if (handlerMap.containsKey(invokeApp)) {
						Map<String, RPCCallableMethod> appMap = handlerMap.get(invokeApp);
						
						if (appMap.containsKey(invokeMethod)) {
							RPCCallableMethod method = appMap.get(invokeMethod);
							
							JSONObject invokeArgs = invokeRequest.getJSONObject("args");
							try {
								JSONObject value = method.handleCall(invokeArgs);
								
								addOKResponse(invokeResponse, value);

							} catch (Exception e) {
								addErrorResponse(invokeResponse, "Error in method call: " + e.getMessage(), invokeRequest);
							}
						} else {
							addErrorResponse(invokeResponse, "No method \"" + invokeMethod + "\" exists in app \"" + invokeApp + "\"", invokeRequest);
						}
					} else {
						addErrorResponse(invokeResponse, "No app \"" + invokeApp + "\" exists", invokeRequest);
					}
				} else {
					addErrorResponse(invokeResponse, "Expected type \"invoke\"", invokeRequest);
				}
				
				synchronized (connections) {
					connections--;
					if (connections < 0) {
						Log.e(TAG, "connections fell below 0");
						connections = 0;
					}
				}
				Log.d(TAG, "connections: " + connections);
				
				msgHandler.sendMessage(invokeResponse);

				// Close the socket
				socket.close();
			} catch (JSONException e) {
				Log.e(TAG, "Error processing JSON object: " + e.getMessage());
			} catch (IOException e) {
				Log.e(TAG, "Error while processing request: " + e.getMessage());
			} catch (Exception e) {
				Log.e(TAG, "Exception thrown: " + e.getMessage());
			}
		}
		
	}

	private JSONObject createEmptyResponse(int callid){
		JSONObject response = new JSONObject();
		
		try {
			response.put("id", rand.nextInt());
			response.put("host", "");
			response.put("callid", callid);
		} catch (JSONException e){
			// This shouldn't happen, just return empty JSONObject
		}

		return response;
	}
	
	private void addOKResponse(JSONObject response, JSONObject value){
		try {
			response.put("type", "OK");
			
			if (value != null){
				response.put("value", value);
			}
		} catch (JSONException e){
			// Shouldn't happen
		}
	}
	
	private void addErrorResponse(JSONObject response, String errorMessage, JSONObject callargs){
		try {
			if (callargs == null){
				response.put("msg", errorMessage);
			} else {
				response.put("message", errorMessage);
				response.put("callargs", callargs);
			}
			response.put("type", "ERROR");
		} catch (JSONException e){
			// Shouldn't happen
		}
	}

}
